package com.towel.swing.table;

import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;

import javax.swing.JOptionPane;
import javax.swing.JTable;
import javax.swing.event.TableColumnModelEvent;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.JTableHeader;
import javax.swing.table.TableModel;

import com.towel.swing.GuiUtils;
import com.towel.swing.TextUtils;
import com.towel.swing.table.adapter.TableColumnModelAdapter;
import com.towel.swing.table.headerpopup.HeaderButtonListener;
import com.towel.swing.table.headerpopup.HeaderPopupEvent;
import com.towel.swing.table.headerpopup.HeaderPopupListener;
import com.towel.swing.table.headerpopup.TableHeaderPopup;


/**
 * TableFilter is a decorator for TableModel adding auto-filter functionality to
 * a supplied TableModel. TODO javadoc me
 * 
 * @author Vinicius Godoy
 */
public class TableFilter extends AbstractTableModel
{
    public enum Sorting
    {
        NONE, ASCENDING, DESCENDING
    };

    /**
     * Default comparator using objects that implements <code>Comparable</code>
     * interface
     */
    private static final Comparator<Object> COMPARABLE_COMPARATOR = new Comparator<Object>()
    {
        @SuppressWarnings("unchecked")
        public int compare(Object o1, Object o2)
        {
            if (o1 == o2)
                return 0;

            if (o1 == null)
                return 1;

            if (o2 == null)
                return -1;

            if (o1 instanceof String)
                return Collator.getInstance().compare(o1, o2);

            return ((Comparable) o1).compareTo(o2);
        }
    };

    /** Default comparator using <code>toString</code> method from objects */
    private static final Comparator<Object> LEXICAL_COMPARATOR = new Comparator<Object>()
    {
        public int compare(Object o1, Object o2)
        {
            if (o1 == o2)
                return 0;

            if (o1 == null)
                return 1;

            if (o2 == null)
                return -1;

            return Collator.getInstance().compare(o1.toString(), o2.toString());
        }
    };

    private static final String POPUP_ITM_SORT_DESC = "Sort by descending order";
    private static final String POPUP_ITM_SORT_ASC = "Sort by ascending order";
    private static final String POPUP_CUSTOMIZE = "Customize";
    private static final String POPUP_EMPTY = "Empty";
    private static final String POPUP_ITM_ALL = "(All)";

    private static final int NO_COLUMN = -1;

    private Map<Integer, Filter> filters = null;
    private Map<Integer, List<Integer>> filterByColumn = null;
    private TableModel tableModel;
    private List<Integer> filteredRows;

    private TableHeaderPopup tableHeaderPopup;
    private HeaderPopupListener listener;

    private Set<Integer> disableColumns;
    private Set<Integer> sortedOnlyColumn;
    private Set<Integer> upToDateColumns;

    private Integer sortingColumn = NO_COLUMN;
    private Sorting order = Sorting.NONE;

    private JTableHeader header;

    /**
     * Table filter constructor. This is the only way to set a TableHeader and a
     * TableModel to this TableFilter.The class will use TableHeader to create
     * a PopUp menu and draw a Button on Table, and the TableModel to get the
     * table data.
     * 
     * @param table JTable to create the Filter on. 
     */
    public TableFilter(JTable table){
    	this(table.getTableHeader(), table.getModel());
    	table.setModel(this);
    }
    
    /**
     * Table filter constructor. This is the only way to set a TableHeader and a
     * TableModel to this TableFilter. The class will use TableHeader to create
     * a PopUp menu and draw a Button on Table, and the TableModel to get the
     * table data.
     * 
     * @param tableHeader Header from JTable where will be draw a Button and
     *        display the PopUp menu.
     * @param tableModel TableModel that return the data to display on table.
     */
    public TableFilter(JTableHeader tableHeader, TableModel tableModel)
    {
        this.filters = new HashMap<Integer, Filter>();
        this.filteredRows = new ArrayList<Integer>();

        this.disableColumns = new TreeSet<Integer>();
        this.sortedOnlyColumn = new TreeSet<Integer>();
        this.upToDateColumns = new HashSet<Integer>();
        this.filterByColumn = new HashMap<Integer, List<Integer>>();

        this.header = tableHeader;
        tableHeader.getColumnModel().addColumnModelListener(
                new TableColumnModelAdapter()
                {
                    @Override
                    public void columnAdded(TableColumnModelEvent e)
                    {
                        int modelIndex = header.getColumnModel().getColumn(
                                e.getToIndex()).getModelIndex();
                        refreshHeader(modelIndex);
                    }
                });

        setTableValues(tableHeader, tableModel);
    }

    /**
     * @param modelIndex
     */
    private void refreshHeader(int column)
    {
        tableHeaderPopup.getPopup(column).removeAllElements();

        if (disableColumns.contains(column))
            return;

        tableHeaderPopup.getPopup(column).addElement(0, null);
    }

    /**
     * Sets if the column auto-filter is enabled or not.
     * 
     * @param column Index from column that will be disabled.
     * @param enabled True if the column auto-filter is enabled.
     */
    public void setColumnFilterEnabled(Integer column, boolean enabled)
    {
        if (!enabled)
            disableColumns.add(column);
        else
            disableColumns.remove(column);
        refreshHeader(column);
        updateFilter();
    }

    public void setColumnSortedOnly(Integer column, boolean onlySorted)
    {
        if (onlySorted)
            sortedOnlyColumn.add(column);
        else
            sortedOnlyColumn.remove(column);
        updateFilter();
    }

    /**
     * Return all filter options from a specific column.
     * 
     * @param columnIndex
     * @return All possible values of the column.
     */
    public Set<Object> getFilterOptions(int columnIndex)
    {
        Set<Object> set = new TreeSet<Object>(getColumnComparator(columnIndex));

        if (!isFiltering())
        {
            for (int i = 0; i < getRowCount(); i++)
                set.add(getValueAt(i, columnIndex));

            return set;
        }

        List<Integer> itens = new ArrayList<Integer>();
        processFilter(itens, columnIndex);
        for (int row : itens)
            set.add(tableModel.getValueAt(row, columnIndex));

        return set;
    }

    /**
     * Sets a filter a table column. Only row with the value equals from filter
     * will be shown.
     * 
     * @param columnIndex
     * @param filter Filter value.
     */
    public void setFilter(int columnIndex, Filter filter)
    {
        filters.put(columnIndex, filter);
        tableHeaderPopup.setModified(columnIndex, true);
        updateFilter();
    }

    public String getFilterString(int columnIndex)
    {
        if (filters.containsKey(columnIndex))
            return filters.get(columnIndex).toString();
        return null;
    }

    public Filter getFilter(int columnIndex)
    {
        return filters.get(columnIndex);
    }

    public void setFilterByString(int columnIndex, String filter)
    {
        setFilter(columnIndex, new StringFilter(filter));
    }

    public void setFilterByRegex(int columnIndex, String filter)
    {
        setFilter(columnIndex, new RegexFilter(filter));
    }

    /**
     * Removes a filter from column.
     * 
     * @param columnIndex
     */
    public void removeFilter(int columnIndex)
    {
        filters.remove(columnIndex);
        updateFilter();
    }

    private void updateFilter()
    {
        updateFilter(true);
    }

    /**
     * Updates the table using the filter values. Only row filtered will be
     * shown.
     */
    private void updateFilter(boolean fireDataChanged)
    {
        generateColumnsIndices();
        processFilter();
        sortColumn();
        upToDateColumns.clear();

        if (fireDataChanged)
            fireTableDataChanged();
    }

    private void generateColumnsIndices()
    {
        filterByColumn.clear();

        for (int column = 0; column < tableModel.getColumnCount(); column++)
        {
            List<Integer> columnFilter = new ArrayList<Integer>();
            for (int i = 0; i < tableModel.getRowCount(); i++)
                columnFilter.add(i);
            filterByColumn.put(column, columnFilter);

            if (filters.get(column) == null)
                continue;

            Iterator<Integer> it = columnFilter.iterator();
            while (it.hasNext())
            {
                int row = it.next();
                Object obj = tableModel.getValueAt(row, column);
                if (!filters.get(column).doFilter(obj))
                    it.remove();
            }
        }
    }

    private void processFilter()
    {
        if (!isFiltering())
            return;

        processFilter(filteredRows, NO_COLUMN);
    }

    private void processFilter(List<Integer> filter, int except)
    {

        filter.clear();
        for (int i = 0; i < tableModel.getRowCount(); i++)
            filter.add(i);

        for (int i = 0; i < filterByColumn.size(); i++)
        {
            if (i != except)
                filter.retainAll(filterByColumn.get(i));
        }
    }

    /**
     * Sorts a column by descending or ascending order.
     */
    private void sortColumn()
    {
        if (!isSorting())
            return;

        Collections.sort(filteredRows, new Comparator<Integer>()
        {
            public int compare(Integer o1, Integer o2)
            {
                Object obj1 = tableModel.getValueAt(o1, sortingColumn);
                Object obj2 = tableModel.getValueAt(o2, sortingColumn);

                if (order == Sorting.ASCENDING)
                    return getColumnComparator(sortingColumn).compare(obj1,
                            obj2);

                return getColumnComparator(sortingColumn).compare(obj2, obj1);
            }

        });
    }

    /**
     * Returns the comparator from column. The comparator must be
     * <code>COMPARABLE_COMPARATOR</code> or <code>LEXICAL_COMPARATOR</code>.
     * 
     * @param column
     * @return
     */
    private Comparator<Object> getColumnComparator(Integer column)
    {
        if (Comparable.class.isAssignableFrom(tableModel.getColumnClass(column)))
            return COMPARABLE_COMPARATOR;

        return LEXICAL_COMPARATOR;
    }

    /**
     * Update the specific column popup menu from table header. Will be inserted
     * into popup menu the sorting itens and the possible values for filter.
     * 
     * @param column Column index.
     */
    private void updateColumnPopup(int column)
    {
        if (upToDateColumns.contains(column))
            return;

        upToDateColumns.add(column);

        tableHeaderPopup.getPopup(column).removeAllElements();

        if (disableColumns.contains(column))
            return;

        tableHeaderPopup.getPopup(column).addElement(POPUP_ITM_SORT_ASC,
                getHeaderPopupListener());
        tableHeaderPopup.getPopup(column).addElement(POPUP_ITM_SORT_DESC,
                getHeaderPopupListener());
        tableHeaderPopup.getPopup(column).addElement(POPUP_CUSTOMIZE,
                getHeaderPopupListener());
        tableHeaderPopup.getPopup(column).addElement(POPUP_EMPTY,
                getHeaderPopupListener());
        tableHeaderPopup.getPopup(column).addListSeparator();

        tableHeaderPopup.getPopup(column).addElement(POPUP_ITM_ALL,
                getHeaderPopupListener());

        if (sortedOnlyColumn.contains(column))
            return;

        Set<Object> filterOptions = getFilterOptions(column);
        for (Object obj : filterOptions)
            tableHeaderPopup.getPopup(column).addElement(obj,
                    getHeaderPopupListener());
    }

    public void setSorting(int index, Sorting order)
    {
        if (order == Sorting.NONE)
        {
            if (sortingColumn == index)
            {
                sortingColumn = NO_COLUMN;
                this.order = Sorting.NONE;
            }
            return;
        }

        if (sortingColumn != NO_COLUMN && !filters.containsKey(sortingColumn))
            tableHeaderPopup.setModified(sortingColumn, false);

        sortingColumn = index;
        this.order = order;
        tableHeaderPopup.setModified(index, true);
        updateFilter();
    }

    /**
     * Creates by lazy creation and return a listener for Header Popup.
     * 
     * @return The created listener.
     */
    private HeaderPopupListener getHeaderPopupListener()
    {
        if (listener == null)
        {
            listener = new HeaderPopupListener()
            {
                public void elementSelected(HeaderPopupEvent e)
                {
                    if (e.getSource().equals(POPUP_ITM_SORT_ASC))
                    {
                        setSorting(e.getModelIndex(), Sorting.ASCENDING);
                    }
                    else if (e.getSource().equals(POPUP_ITM_SORT_DESC))
                    {
                        setSorting(e.getModelIndex(), Sorting.DESCENDING);
                    }
                    else if (e.getSource().equals(POPUP_ITM_ALL))
                    {
                        setSorting(e.getModelIndex(), Sorting.NONE);
                        removeFilter(e.getModelIndex());
                        tableHeaderPopup.setModified(e.getModelIndex(), false);
                    }
                    else if (e.getSource().equals(POPUP_CUSTOMIZE))
                    {
                        String text = "";
                        if (filters.get(e.getModelIndex()) instanceof RegexFilter)
                            text = ((RegexFilter) filters.get(e.getModelIndex())).getRegex();

                        String value = JOptionPane.showInputDialog(
                                GuiUtils.getOwnerWindow(header),
                                "Customize filter (you can use wildcards * or ?)",
                                text);

                        if (value == null)
                            return;

                        setFilterByRegex(e.getModelIndex(), value);
                    }
                    else if (e.getSource().equals(POPUP_EMPTY))
                    {
                        setFilterByString(e.getModelIndex(), "");
                    }
                    else
                    {
                        setFilterByString(e.getModelIndex(),
                                e.getSource().toString());
                    }
                }
            };
        }

        return listener;
    }

    public int getColumnCount()
    {
        return tableModel.getColumnCount();
    }

    public int getRowCount()
    {
        if (isFiltering())
            return filteredRows.size();
        return tableModel.getRowCount();
    }

    public Object getValueAt(int rowIndex, int columnIndex)
    {
        return tableModel.getValueAt(getModelRow(rowIndex), columnIndex);
    }

    /**
     * Sets the TableHeader and TableModel on this TableFilter. This method must
     * be used only on contructor.
     * 
     * @param header
     * @param tableModel
     */
    private void setTableValues(JTableHeader header, TableModel tableModel)
    {
        this.tableModel = tableModel;
        this.tableHeaderPopup = new TableHeaderPopup(header, tableModel);

        tableHeaderPopup.addButtonListener(new HeaderButtonListener()
        {
            public void buttonClicked(HeaderPopupEvent e)
            {
                updateColumnPopup(e.getModelIndex());
            }
        });

        tableModel.addTableModelListener(new TableModelListener()
        {
            public void tableChanged(TableModelEvent e)
            {
                onTableChanged(e);
            }
        });

        updateFilter();
    }

    /**
     * Method called on TableChanged event generated from TableModel. This is a
     * very important method the TableModel is adding or removing an object from
     * table. If the TableModel is adding an object to table, this object will
     * be shown independent of the filter values.
     * 
     * @param e
     */
    private void onTableChanged(TableModelEvent e)
    {
        if (e.getType() == TableModelEvent.INSERT)
        {
            int first = filteredRows.size();
            int last = filteredRows.size();

            for (int row = e.getFirstRow(); row <= e.getLastRow(); row++)
            {
                filteredRows.add(row);
                last++;
            }

            fireTableRowsInserted(first, last - 1);
            upToDateColumns.clear(); // invalidate header popup
        }
        else if (e.getType() == TableModelEvent.DELETE)
        {
            if (!isFiltering())
            {
                fireTableRowsDeleted(e.getFirstRow(), e.getLastRow());
                upToDateColumns.clear(); // invalidate header popup
                return;
            }

            for (int row = e.getLastRow(); row >= e.getFirstRow(); row--)
            {
                int index = filteredRows.indexOf(row);

                if (index != -1)
                {
                    filteredRows.remove(index);
                    fireTableRowsDeleted(index, index);
                }
            }

            // shift up nRemoved times the index of filteredRows
            int nRemoved = e.getLastRow() - e.getFirstRow() + 1;
            for (int i = 0; i < filteredRows.size(); i++)
            {
                if (filteredRows.get(i) > e.getLastRow())
                    filteredRows.set(i, filteredRows.get(i) - nRemoved);
            }

            upToDateColumns.clear(); // invalidate header popup
        }
        else if (e.getType() == TableModelEvent.UPDATE)
        {
            if (e.getColumn() == TableModelEvent.ALL_COLUMNS)
            {
                if (!isFiltering())
                {
                    fireTableDataChanged();
                    upToDateColumns.clear(); // invalidate header popup
                    return;
                }

                if (e.getLastRow() == Integer.MAX_VALUE)
                {
                    // TableDataChanged!
                    Integer currentSortingColumn = sortingColumn;
                    Sorting currentOrder = order;
                    Map<Integer, Filter> currentFilters = new HashMap<Integer, Filter>(
                            filters);

                    sortingColumn = NO_COLUMN;
                    order = Sorting.NONE;
                    filters.clear();
                    updateFilter(false);

                    sortingColumn = currentSortingColumn;
                    order = currentOrder;
                    filters.putAll(currentFilters);
                    updateFilter();
                    return;
                }

                for (int row = e.getFirstRow(); row <= e.getLastRow(); row++)
                {
                    int index = filteredRows.indexOf(row);
                    if (index != -1)
                        fireTableRowsUpdated(index, index);
                }
                upToDateColumns.clear(); // invalidate header popup
            }
            else
            {
                fireTableCellUpdated(e.getFirstRow(), e.getColumn());

                // invalidate header popup from specific column
                upToDateColumns.remove(e.getColumn());
            }
        }
    }

    /**
     * Converts the view index to table model index.
     * 
     * @param viewRow
     * @return Table model index.
     */
    public int getModelRow(int viewRow)
    {
        if (viewRow == -1)
            return -1;

        if (!isFiltering())
            return viewRow;

        return filteredRows.get(viewRow);
    }

    public int[] getModelRows(int[] viewRows)
    {
        int[] modelRows = new int[viewRows.length];
        for (int i = 0; i < viewRows.length; i++)
            modelRows[i] = getModelRow(viewRows[i]);

        return modelRows;
    }

    public int getViewRow(int modelRow)
    {
        if (modelRow == -1)
            return -1;

        if (!isFiltering())
            return modelRow;

        for (int i = 0; i < filteredRows.size(); i++)
            if (modelRow == filteredRows.get(i).intValue())
                return i;

        return -1;
    }

    @Override
    public Class< ? > getColumnClass(int columnIndex)
    {
        return tableModel.getColumnClass(columnIndex);
    }

    @Override
    public String getColumnName(int column)
    {
        return tableModel.getColumnName(column);
    }

    @Override
    public boolean isCellEditable(int rowIndex, int columnIndex)
    {
        return tableModel.isCellEditable(getModelRow(rowIndex), columnIndex);
    }

    @Override
    public void setValueAt(Object aValue, int rowIndex, int columnIndex)
    {
        tableModel.setValueAt(aValue, getModelRow(rowIndex), columnIndex);
    }

    /**
     * @return the Table Model.
     */
    public TableModel getTableModel()
    {
        return tableModel;
    }

    /**
     * Get filtered rows.
     * 
     * @return A list of rows.
     */
    public List<Integer> getFilteredRows()
    {
        return Collections.unmodifiableList(filteredRows);
    }

    public boolean isFiltering()
    {
        return !filters.isEmpty() || isSorting();
    }

    public boolean isSorting()
    {
        return sortingColumn != NO_COLUMN && order != Sorting.NONE;
    }

    public Sorting getOrder()
    {
        return order;
    }

    public Integer getSortingColumn()
    {
        return sortingColumn;
    }

    public static interface Filter
    {
        boolean doFilter(Object obj);
    }

    public static class StringFilter implements Filter
    {
        private String string = "";

        public StringFilter()
        {
        }

        public StringFilter(String str)
        {
            this.string = str;
        }

        public boolean doFilter(Object obj)
        {
            String objStr = obj == null ? "" : obj.toString();
            return string.equals(objStr);
        }

        public String getString()
        {
            return string;
        }

        public void setString(String string)
        {
            this.string = string;
        }
    }

    public static class RegexFilter implements Filter
    {
        private String regex = "";

        public RegexFilter()
        {
        }

        public RegexFilter(String regex)
        {
            this.regex = regex;
        }

        public boolean doFilter(Object obj)
        {
            String regex = TextUtils.generateEscapeRegex(this.regex.toLowerCase());
            regex = regex.replaceAll("\\\\\\*", ".*");
            regex = regex.replaceAll("\\\\\\?", ".");

            String objStr = obj == null ? "" : obj.toString().toLowerCase();
            return Pattern.matches(regex, objStr);
        }

        public String getRegex()
        {
            return regex;
        }

        public void setRegex(String regex)
        {
            this.regex = regex;
        }
    }
}
